Skip to content

improvement(chat): smooth streaming — eased stick-to-bottom glide and time-based reveal pacing#5417

Merged
TheodoreSpeaks merged 3 commits into
stagingfrom
improvement/smooth-stream-scroll
Jul 4, 2026
Merged

improvement(chat): smooth streaming — eased stick-to-bottom glide and time-based reveal pacing#5417
TheodoreSpeaks merged 3 commits into
stagingfrom
improvement/smooth-stream-scroll

Conversation

@TheodoreSpeaks

@TheodoreSpeaks TheodoreSpeaks commented Jul 4, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Auto-scroll now glides to the bottom instead of snapping: each frame closes 12% of the remaining gap (same easing as the subagent viewport, extracted into a shared smooth-bottom-chase util). Detach/re-attach semantics unchanged — chase writes only move down, so they can never read as a user scroll; verified 0px drift after a wheel-up detach mid-stream.
  • Text reveal is time-based instead of fixed-interval chunks: an rAF loop earns a chars/sec budget and releases one word boundary at a time, evenly spaced. The rate is proportional (drain the backlog over ~400ms), so it converges on the model's real arrival rate instead of racing ahead and stalling — kills both the multi-word chunk pops and the burst–pause rhythm.
  • Chat-only by design: the file-streaming variants were prototyped and dropped (ProseMirror setContent fights the chase's user-scroll interrupt; Monaco should use its native smoothScrolling instead).

Type of Change

  • Improvement

Testing

Tested manually in the live app: glide follows tightly during streaming (gap decays smoothly, parks at 0), wheel-up detach shows zero fight-back over 1.5s of continued streaming, re-attach at bottom works, and reveal cadence measured word-at-a-time. vitest smooth-text + message-content suites pass, bun run lint, bun run check:api-validation:strict pass.

Checklist

  • Code follows project style guidelines
  • Self-reviewed my changes
  • Tests added/updated and passing
  • No new warnings introduced
  • I confirm that I have read and agree to the terms outlined in the Contributor License Agreement (CLA)

@vercel

vercel Bot commented Jul 4, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
docs Skipped Skipped Jul 4, 2026 10:49pm

Request Review

@cursor

cursor Bot commented Jul 4, 2026

Copy link
Copy Markdown

PR Summary

Low Risk
UI-only scroll and reveal pacing in chat hooks; detach semantics are preserved and behavior is localized to presentation, though file viewers also share the updated useSmoothText pacing.

Overview
Streaming chat auto-scroll no longer jumps to scrollHeight on each DOM mutation. It uses a shared createSmoothBottomChase helper that eases toward the bottom (~12% of the remaining gap per frame), with the same detach/re-attach rules as before; mutation-driven follow and post-stream followToBottom use the same glide instead of instant snaps.

useSmoothText switches from fixed 24ms timer ticks with tiered chunk sizes to a requestAnimationFrame loop: elapsed time builds a character budget via a proportional drain (~400ms horizon, min/max CPS), and reveal advances one word/punctuation boundary at a time so pacing tracks the model’s arrival rate instead of burst-then-pause.

New util: apps/sim/lib/core/utils/smooth-bottom-chase.ts (extracted from subagent viewport behavior), wired from use-auto-scroll.

Reviewed by Cursor Bugbot for commit d43a39c. Bugbot is set up for automated code reviews on this repo. Configure here.

@greptile-apps

greptile-apps Bot commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR replaces two "snap" behaviors in the chat view with smooth, time-based alternatives. Auto-scroll now uses an exponential glide (closing 12% of the remaining gap per frame) shared via a new createSmoothBottomChase utility, and text reveal switches from a fixed-interval setTimeout tick to a per-frame rAF loop that earns a character budget proportional to the current backlog, releasing one word boundary at a time.

  • smooth-bottom-chase.ts — new shared utility; exports createSmoothBottomChase with kick/cancel/isActive handle, upward-scroll self-interrupt, and a configurable shouldContinue guard.
  • use-auto-scroll.ts — replaces scrollToBottom() + cancelAnimationFrame with chase.kick() throughout; detach/re-attach semantics and followToBottom settle window are unchanged.
  • use-smooth-text.tssetTimeout tick replaced with a requestAnimationFrame loop; introduces drainRate (proportional controller clamped to 45–2400 CPS), fractional budgetRef carry-over, and a 100ms dt clamp to prevent background-tab budget accumulation.

Confidence Score: 4/5

Safe to merge — the scroll and text-reveal changes are self-contained UI improvements with no data-path or state-store impact.

The easing and budget logic are correct, detach/re-attach semantics are preserved, and the new createSmoothBottomChase utility is well-guarded against user-scroll fighting. The stale scrollToBottom dependency in the effect array and the isActive() transient-false window in smooth-bottom-chase are both minor style issues with no observable runtime consequence today.

smooth-bottom-chase.ts — the raf = null at the top of step() creates a brief window where isActive() returns false; worth hardening before this utility gets additional callers beyond use-auto-scroll.ts.

Important Files Changed

Filename Overview
apps/sim/lib/core/utils/smooth-bottom-chase.ts New shared utility for eased stick-to-bottom scrolling; logic is sound but isActive() transiently returns false mid-step and the rAF handle is set to null at the top of step() before re-queuing at the bottom.
apps/sim/hooks/use-auto-scroll.ts Switches mutation-observer-driven scroll to use createSmoothBottomChase; scrollToBottom remains in the effect dependency array despite no longer being called inside the effect body.
apps/sim/hooks/use-smooth-text.ts Replaces fixed-interval setTimeout ticks with a time-budgeted rAF loop; budget arithmetic, boundary snapping, backlog drain rate, and dt clamping are all correct.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Stream as Streaming Content
    participant MO as MutationObserver
    participant Chase as createSmoothBottomChase
    participant rAF as requestAnimationFrame
    participant El as scroll container

    Stream->>MO: DOM mutation (new text)
    MO->>Chase: kick()
    Chase->>rAF: schedule step()
    loop "Each frame (gap > CHASE_REST_GAP)"
        rAF->>Chase: step()
        Chase->>El: getTop()
        Chase->>El: setTop(top + max(1, gap x 0.12))
        Chase->>El: "getTop() -> lastTop"
        Chase->>rAF: schedule next step()
    end
    Note over Chase,El: Parks when gap <= 0.5px
    Stream->>MO: DOM mutation (word arrives)
    MO->>Chase: kick() no-op raf not null
    Note over Chase: User scrolls UP
    rAF->>Chase: "step() top < lastTop-1"
    Chase->>Chase: park() stops fighting user
    Note over Stream: isStreaming false
    Stream->>Chase: cancel()
    Stream->>El: followToBottom(300ms) via rAF
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant Stream as Streaming Content
    participant MO as MutationObserver
    participant Chase as createSmoothBottomChase
    participant rAF as requestAnimationFrame
    participant El as scroll container

    Stream->>MO: DOM mutation (new text)
    MO->>Chase: kick()
    Chase->>rAF: schedule step()
    loop "Each frame (gap > CHASE_REST_GAP)"
        rAF->>Chase: step()
        Chase->>El: getTop()
        Chase->>El: setTop(top + max(1, gap x 0.12))
        Chase->>El: "getTop() -> lastTop"
        Chase->>rAF: schedule next step()
    end
    Note over Chase,El: Parks when gap <= 0.5px
    Stream->>MO: DOM mutation (word arrives)
    MO->>Chase: kick() no-op raf not null
    Note over Chase: User scrolls UP
    rAF->>Chase: "step() top < lastTop-1"
    Chase->>Chase: park() stops fighting user
    Note over Stream: isStreaming false
    Stream->>Chase: cancel()
    Stream->>El: followToBottom(300ms) via rAF
Loading

Comments Outside Diff (2)

  1. apps/sim/hooks/use-auto-scroll.ts, line 262 (link)

    P2 scrollToBottom is no longer called inside this effect — chase.kick() replaced both the seed call and the guardedScroll path. The function is still stable (empty useCallback deps), so leaving it here is harmless, but it makes the dependency array misleading for future maintainers and would trigger react-hooks/exhaustive-deps if the rule is ever enabled with checkMissingDependencies.

    Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

  2. apps/sim/hooks/use-auto-scroll.ts, line 208-223 (link)

    P2 followToBottom duplicates the easing step from createSmoothBottomChase

    followToBottom inlines el.scrollTop = el.scrollTop + Math.max(1, gap * SMOOTH_CHASE_RATE) and the CHASE_REST_GAP check rather than delegating to createSmoothBottomChase. The constants are shared via import so they stay in sync today, but the Math.max(1, ...) clamp and CHASE_REST_GAP rest condition would need to be manually updated in two places if the easing formula ever changes. Since the only behavioural difference between followToBottom and the chase object is the time bound (performance.now() > until) and the absence of lastTop upward-interrupt detection, it could be wrapped into createSmoothBottomChase via an optional maxDurationMs parameter, or the loop could simply delegate setTop to the shared helper while retaining its own frame-scheduling.

    Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Reviews (1): Last reviewed commit: "improvement(chat): time-based word-at-a-..." | Re-trigger Greptile

Comment thread apps/sim/lib/core/utils/smooth-bottom-chase.ts
@TheodoreSpeaks TheodoreSpeaks merged commit f456ed9 into staging Jul 4, 2026
17 checks passed
@TheodoreSpeaks TheodoreSpeaks deleted the improvement/smooth-stream-scroll branch July 4, 2026 23:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant